Project Poster: HDI vs GDP Per Capita Visualization

Authors

Tan Zi Xu

Teo Royston

Sim Yue Chong Samuel

Ng Kay Cheng

Ramasubramanian Srinithi

Published

July 29, 2025

1 . Loading libraries

library(readr)
library(dplyr)
library(plotly)
library(scales)
library(RColorBrewer)
library(htmltools)
library(htmlwidgets)
library(jsonlite)
library(stringr)
library(DT)
library(glue)
library(knitr)
library(gt)

2 . Data Loading

df <- read.csv("https://ourworldindata.org/grapher/human-development-index-vs-gdp-per-capita.csv?v=1&csvType=full&useColumnShortNames=true")

3 . Data Cleaning & Transformation

3.1 Cleaning the Data

# 1. Start with the raw data
orig_n <- nrow(df)

# 2. Filter to the year range
df1 <- df |>
  filter(Year >= 1990, Year <= 2023)
after_year_n <- nrow(df1)
cat(glue("✔ Kept {after_year_n} rows between 1990 and 2023 (dropped {orig_n - after_year_n}).\n"))
✔ Kept 9457 rows between 1990 and 2023 (dropped 50095).
# 3. Keep only proper 3-letter codes
invalid_codes <- df1 |>
  filter(!str_detect(Code, "^[A-Z]{3}$")) |>
  pull(Code) |>
  unique()
df2 <- df1 |>
  filter(str_detect(Code, "^[A-Z]{3}$"))
after_code_n <- nrow(df2)
cat(glue("✔ Kept {after_code_n} rows with 3-letter codes; dropped {after_year_n - after_code_n} rows (codes: {toString(invalid_codes)}).\n"))
✔ Kept 8087 rows with 3-letter codes; dropped 1370 rows (codes: , OWID_AKD, OWID_AUH, OWID_CZS, OWID_GDR, OWID_ERE, OWID_KRU, OWID_KOS, OWID_RVN, OWID_SRM, OWID_USS, OWID_GFR, OWID_WRL, OWID_YAR, OWID_YPR, OWID_YGS).
# 4. Rename & drop rows missing HDI or GDP
df_clean <- df2 |>
  rename(
    hdi = "hdi__sex_total",
    gdp = "ny_gdp_pcap_pp_kd"
  )

before_drop_na <- nrow(df_clean)
df_clean <- df_clean |>
  filter(!is.na(hdi), !is.na(gdp))
after_drop_na <- nrow(df_clean)
cat(glue("✔ Renamed columns; dropped {before_drop_na - after_drop_na} rows missing HDI or GDP.\n"))
✔ Renamed columns; dropped 2339 rows missing HDI or GDP.
# 4.5 Fill in owid_region from each country’s 2023 observation
region_lookup <- df_clean |>
  filter(Year == 2023) |>
  select(Entity, owid_region) |>
  distinct()

df_clean <- df_clean |>
  select(-owid_region) |>
  left_join(region_lookup, by = "Entity") |>
  relocate(owid_region, .after = population_historical)

cat(glue("✔ Filled `owid_region` for all countries based on 2023 data.\n"))
✔ Filled `owid_region` for all countries based on 2023 data.
# 5. Final summary
cat(glue("✅ Final cleaned dataset: {nrow(df_clean)} rows, {ncol(df_clean)} columns.\n"))
✅ Final cleaned dataset: 5748 rows, 7 columns.

3.2 Cleaned Data Summary

# Render as interactive table
DT::datatable(
  df_clean,
  caption = "Final Cleaned Dataset",
  rownames = FALSE,
  options = list(
    pageLength   = 10,
    lengthChange = FALSE,
    autoWidth    = TRUE
  )
)

3.3 Engineering New Features (Flagging Development Status & Banding)

df_flagged <- df_clean |>
  mutate(
    dev_status = if_else(
      hdi >= 0.80 & gdp > 12536,
      "Developed",
      "Developing"
    )
  )

df_banded <- df_flagged |>
  mutate(
    # HDI categories: Low (<0.55), Medium (0.55–0.70), High (0.70–0.80), Very High (≥0.80)
    hdi_band = case_when(
      hdi < 0.55 ~ "Low",
      hdi < 0.70 ~ "Medium",
      hdi < 0.80 ~ "High",
      TRUE       ~ "Very High"
    ),
    # GDP per capita categories (World Bank income groups)
    gdp_band = case_when(
      gdp < 1086   ~ "Low-income",
      gdp < 4255   ~ "Lower-middle",
      gdp < 13206  ~ "Upper-middle",
      TRUE         ~ "High-income"
    )
  )

3.4 Banded Data Summary

Showing band changes for: South Korea, United Arab Emirates, Bangladesh

3.5 Statistics by Year

stats_by_year <- df_banded |>
  group_by(Year, dev_status) |>
  summarize(
    count       = n(),              # number of country‐observations
    avg_hdi     = mean(hdi),        # mean HDI
    avg_gdp     = mean(gdp),        # mean GDP per capita
    hdi_min     = min(hdi),         # minimum HDI
    hdi_median  = median(hdi),      # median HDI
    hdi_max     = max(hdi),         # maximum HDI
    gdp_min     = min(gdp),         # minimum GDP per capita
    gdp_median  = median(gdp),      # median GDP per capita
    gdp_max     = max(gdp),         # maximum GDP per capita
    .groups     = "drop"
  )

stats_by_year |>
  slice_head(n = 8) |>
  datatable(
    options = list(
      dom = "t", 
      autoWidth  = TRUE
    ),
    rownames = FALSE
  )
# Uncomment to save the transformed data and stats
# write.csv(df_banded, "data/transformed_data.csv", row.names = FALSE)
# write.csv(stats_by_year, "data/stats.csv", row.names = FALSE)

4 . Improving Visualization

4.1 Final preparation (Post Proposal)

# 1) Load & prep scatter data
df <- read_csv("data/transformed_data.csv", show_col_types = FALSE) |>
  filter(!is.na(owid_region)) |>
  mutate(
    hover_text = paste0(
      "<b>", Entity, "</b><br>",
      "GDP: $", comma(round(gdp)), "<br>",
      "HDI: ", round(hdi, 3), "<br>",
      "Pop: ", comma(round(population_historical)), "<br>",
      "Status: ", dev_status
    )
  )

# 2) Load per‑year stats (Year, dev_status, count, avg_gdp, avg_hdi)
stats <- read_csv("data/stats.csv", show_col_types = FALSE)
years <- sort(unique(df$Year))
years_json <- toJSON(years, auto_unbox = TRUE)
stats_json <- toJSON(stats, dataframe = "rows", auto_unbox = TRUE)

# 3) Create country mapping for milestone events - only multi-country events
# Using multiple naming variations to match different dataset conventions
country_groups <- list(
  "1991" = c("Russia", "Ukraine", "Belarus", "Kazakhstan", "Georgia", "Armenia", "Azerbaijan", "Estonia", "Latvia", "Lithuania"), 
  "1997" = c("Thailand", "Indonesia", "South Korea", "Malaysia", "Philippines", "Singapore", "Hong Kong"), 
  "2001" = c("Argentina", "Brazil", "Uruguay", "Paraguay", "Ecuador"), 
  "2008" = c("Iceland", "Ireland", "Greece", "Portugal", "Spain", "Italy"), 
  "2012" = c("Greece", "Spain", "Portugal", "Italy", "Cyprus", "Ireland"), 
  "2014" = c("Russia", "Ukraine", "Belarus", "Kazakhstan"), 
  "2015" = c("Greece", "Germany", "France", "Italy", "Spain", "Portugal"),
  "2020" = c("India", "Brazil", "Mexico", "Iran", "Peru", "Colombia", "South Africa"), 
  "2022" = c("Sri Lanka", "Pakistan", "Turkey", "Argentina", "Lebanon")
)

country_groups_json <- toJSON(country_groups, auto_unbox = TRUE)

# 4) Prepare background HDI/GDP bands
hdi_bands <- tibble::tibble(
  ymin = c(0,0.55,0.70,0.80),
  ymax = c(0.55,0.70,0.80,1.00),
  fill = c("#f7fbff","#deebf7","#9ecae1","#3182bd")
)
cuts      <- c(1,1086,4255,13206, max(df$gdp,na.rm=TRUE))
gdp_bands <- tibble::tibble(
  xmin = cuts[-length(cuts)],
  xmax = cuts[-1],
  fill = c("#fff5f0","#fee0d2","#fc9272","#de2d26")
)

4.2 Drawing Shapes & Annotations

# 5) Prepare Shapes & Annotations
gdp_shapes <- lapply(seq_len(nrow(gdp_bands)), function(i){
  list(type="rect", xref="x",
       x0=gdp_bands$xmin[i], x1=gdp_bands$xmax[i],
       yref="paper", y0=0, y1=1,
       fillcolor=gdp_bands$fill[i],
       line=list(width=0),
       layer="below")
})
hdi_shapes <- lapply(seq_len(nrow(hdi_bands)), function(i){
  list(type="rect", xref="paper",
       x0=0, x1=1,
       yref="y", y0=hdi_bands$ymin[i], y1=hdi_bands$ymax[i],
       fillcolor=hdi_bands$fill[i],
       line=list(width=0),
       layer="below")
})

threshold_shapes <- list(
  # Vertical dotted line up to the meeting point
  list(
    type = "line",
    x0 = 12536, x1 = 12536,
    y0 = 0,     y1 = 0.8,
    line = list(
      dash = "dot",
      width = 2,
      color = "black"
    )
  ),
  # Horizontal dotted line up to the meeting point
  list(
    type = "line",
    x0 = 1,     x1 = 12536,
    y0 = 0.8,   y1 = 0.8,
    line = list(
      dash = "dot",
      width = 2,
      color = "black"
    )
  )
)

max_gdp <- max(df$gdp, na.rm=TRUE)

hdi_annots <- list(
  list(x = -0.11, xref = "paper", y = 0.275, yref = "y", text = "Low HDI",    showarrow = FALSE, xanchor = "right", yanchor = "middle", font = list(size=13)),
  list(x = -0.11, xref = "paper", y = 0.625, yref = "y", text = "Medium HDI", showarrow = FALSE, xanchor = "right", yanchor = "middle", font = list(size=13)),
  list(x = -0.11, xref = "paper", y = 0.75,  yref = "y", text = "High HDI",   showarrow = FALSE, xanchor = "right", yanchor = "middle", font = list(size=13)),
  list(x = -0.11, xref = "paper", y = 0.9,   yref = "y", text = "Very High HDI", showarrow = FALSE, xanchor = "right", yanchor = "middle", font = list(size=13))
)

gdp_annots <- list(
  list(
    x = mean(c(1, 1086)), y = -0.045, text = "Low income", showarrow = FALSE, xanchor = "center", yanchor="top", font = list(size=12)
  ),
  list(
    x = mean(c(1086, 4255)), y = -0.045, text = "Lower-middle income", showarrow = FALSE, xanchor = "center", yanchor="top", font = list(size=12)
  ),
  list(
    x = mean(c(4255, 13206)), y = -0.045, text = "Upper-middle income", showarrow = FALSE, xanchor = "center", yanchor="top", font = list(size=12)
  ),
  list(
    x = mean(c(13206, max_gdp)), y = -0.045, text = "High income", showarrow = FALSE, xanchor = "center", yanchor="top", font = list(size=12)
  )
)

axis_band_annotations <- c(hdi_annots, gdp_annots)

4.3 Building the Animated Scatter Plot

# 6) Build the animated scatter, Assembling
palette <- brewer.pal(8, "Set2")
p <- plot_ly(
  df,
  x         = ~gdp,    y         = ~hdi,
  frame     = ~Year,
  type      = "scatter",  mode = "markers",
  color     = ~owid_region, colors = palette,
  size      = ~population_historical, sizes = c(5,500),
  text      = ~hover_text, hoverinfo = "text",
  marker    = list(opacity=0.8, line=list(color="black",width=1))
)

# 7) Finalize layout
widget <- p |>
  animation_opts(frame=800, transition=300, redraw=FALSE) |>
  layout(
    title = list(
          text = "HDI vs GDP per Capita: Global Trends Over Time",
          x = 0.5,
          font = list(size = 22, family = "Arial", color = "#222")
    ),
    shapes      = c(gdp_shapes, hdi_shapes, threshold_shapes),
    annotations = axis_band_annotations,
    xaxis       = list(
      type     = "log",
      title    = "GDP per capita (international-$, 2021 prices)",
      tickvals = c(1000,5000,10000,50000,100000),
      ticktext = c("$1k","$5k","$10k","$50k","$100k")
    ),
    yaxis       = list(
      title    = "Human Development Index",
      range    = c(0,1),
      tickvals = seq(0,1,0.1)
    ),
    margin      = list(l = 155, b = 90, t = 50),
    legend      = list(title=list(text="Region")),
    updatemenus = list() # Remove the top buttons
  ) |>
  config(displayModeBar=FALSE)

4.4 Building Widgets with Javascript

# 8) Enhanced JavaScript with unified animation control and country highlighting
widget <- widget |>
  htmlwidgets::onRender("
    function(el, x) {
      var gd = document.getElementById(el.id);
      var stats = window.stats_panel_data;
      var years = window.years_panel;
      var countryGroups = window.country_groups_data;
      
      // Updated milestone data with only multi-country events
      var milestoneYears = [1991, 1997, 2001, 2008, 2012, 2014, 2015, 2020, 2022];
      var milestoneTexts = {
        1991: 'USSR Collapse:<br>Transition from communist to<br>market economies, showing rapid<br>development changes<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Former Soviet states</span>',
        1997: 'Asian Financial Crisis:<br>\"Tiger economies\" face severe<br>setbacks, showing vulnerability<br>of emerging markets<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: East/Southeast Asian countries</span>',
        2001: 'Argentina Economic Crisis:<br>Middle-income country faces<br>severe economic collapse,<br>highlighting development fragility<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Latin American countries</span>',
        2008: 'Global Financial Crisis:<br>Even wealthy nations struggle,<br>but developing countries<br>face disproportionate impact<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Most affected developed nations</span>',
        2012: 'European Debt Crisis:<br>Sovereign debt crisis spreads<br>across multiple European nations,<br>threatening the Eurozone<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Crisis-affected European countries</span>',
        2014: 'Oil Price Collapse:<br>Commodity-dependent economies<br>struggle while diversified<br>economies remain stable<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Oil-dependent nations</span>',
        2015: 'European Migration Crisis:<br>Refugee crisis affects multiple<br>European nations, straining<br>resources and social cohesion<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Major destination countries</span>',
        2020: 'COVID-19 Pandemic:<br>Developing nations face<br>disproportionate health and<br>economic impacts vs wealthy nations<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Most affected developing nations</span>',
        2022: 'Debt Crisis Wave:<br>Multiple developing nations<br>face sovereign debt crises<br>while rich nations remain stable<br><span style=\"color:#d62728;font-weight:bold;\">→ Highlighted: Crisis-affected nations</span>'
      };
      
      var isPlaying = false;
      var currentYearIndex = 0;
      var animationTimeout;
      var allYears = [];
      var isTimelineMode = false; // Track which mode we're in
      var originalMarkerData = null; // Store original marker properties
      
      // Extract all years from data
      if (years && years.length) {
        allYears = years.slice(); // Copy array
      }
      
      // Function to stop any running animation
      function stopAnimation() {
        if (isPlaying) {
          isPlaying = false;
          clearTimeout(animationTimeout);
          
          // Only reset highlighting if we're NOT in timeline mode on a milestone year
          var currentYear = allYears[currentYearIndex - 1] || allYears[0]; // Get current year
          var shouldKeepHighlighting = isTimelineMode && 
                                      milestoneYears.includes(currentYear) && 
                                      countryGroups[currentYear.toString()] && 
                                      countryGroups[currentYear.toString()].length > 0;
          
          if (!shouldKeepHighlighting) {
            resetHighlighting();
          }
          
          // Reset all button texts
          var timelineBtn = document.getElementById('timeline-btn');
          var playBtn = document.getElementById('play-btn');
          
          if (timelineBtn && timelineBtn.textContent.includes('Pause')) {
            timelineBtn.textContent = '▶ Play Timeline';
          }
          if (playBtn && playBtn.textContent.includes('Pause')) {
            playBtn.textContent = '▶ Play';
          }
        }
      }
      
      // Function to reset to start
      function resetToStart() {
        stopAnimation();
        currentYearIndex = 0;
        if (allYears.length > 0) {
          var firstYear = allYears[0];
          animateToYear(firstYear);
          updateStats(firstYear);
          var picker = document.getElementById('year-picker');
          if (picker) picker.value = firstYear;
        }
      }
      
      // Function to store original marker data
      function storeOriginalMarkerData() {
        if (!originalMarkerData && gd.data && gd.data[0]) {
          originalMarkerData = {
            opacity: gd.data[0].marker.opacity,
            line: JSON.parse(JSON.stringify(gd.data[0].marker.line))
          };
        }
      }
      
      // Function to highlight specific countries
      function highlightCountries(year, countries) {
        if (!gd.data || !countries || countries.length === 0) {
          return;
        }
        
        storeOriginalMarkerData();
        
        // Get ALL current visible data across ALL traces (regions)
        var allOpacities = [];
        var allLineWidths = [];
        var allLineColors = [];
        var totalHighlighted = 0;
        
        // Loop through ALL traces (one per region)
        for (var traceIndex = 0; traceIndex < gd.data.length; traceIndex++) {
          var trace = gd.data[traceIndex];
          var textData = trace.text;
          
          if (!textData) continue;
          
          var traceOpacities = [];
          var traceLineWidths = [];
          var traceLineColors = [];
          
          // Check each point in this trace
          for (var i = 0; i < textData.length; i++) {
            var text = textData[i];
            var isHighlighted = false;
            
            // Extract country name from hover text
            var match = text.match(/<b>(.*?)<\\/b>/);
            if (match) {
              var countryName = match[1];
              
              // Enhanced country matching with more variations
              isHighlighted = countries.some(function(country) {
                return countryName === country || 
                       countryName.includes(country) || 
                       country.includes(countryName) ||
                       // Specific mappings for common variations
                       (country === 'China' && (countryName === 'China' || countryName === 'People\\'s Republic of China')) ||
                       (country === 'United States' && (countryName === 'United States' || countryName === 'USA' || countryName === 'US')) ||
                       (country === 'Hong Kong' && (countryName.includes('Hong Kong') || countryName === 'Hong Kong SAR China')) ||
                       (country === 'South Korea' && (countryName.includes('Korea') && !countryName.includes('North'))) ||
                       (country === 'Russia' && (countryName.includes('Russian') || countryName === 'Russia')) ||
                       (country === 'Egypt' && countryName.includes('Egypt')) ||
                       (country === 'Yemen' && countryName.includes('Yemen')) ||
                       (country === 'Syria' && countryName.includes('Syria')) ||
                       (country === 'Democratic Republic of Congo' && (countryName.includes('Congo') && countryName.includes('Dem'))) ||
                       (country === 'Congo, Dem. Rep.' && (countryName.includes('Congo') && countryName.includes('Dem')));
              });
            }
            
            if (isHighlighted) {
              traceOpacities.push(1.0);
              traceLineWidths.push(4);
              traceLineColors.push('#d62728'); // Red highlight
              totalHighlighted++;
            } else {
              traceOpacities.push(0.15); // More faded non-highlighted countries
              traceLineWidths.push(1);
              traceLineColors.push('gray');
            }
          }
          
          allOpacities.push(traceOpacities);
          allLineWidths.push(traceLineWidths);
          allLineColors.push(traceLineColors);
        }
        
        // Debug: Log highlighting info
        console.log('Year:', year, 'Expected countries:', countries, 'Total highlighted across all regions:', totalHighlighted);
        
        // Apply highlighting to ALL traces
        var updateObj = {
          'marker.opacity': allOpacities,
          'marker.line.width': allLineWidths,
          'marker.line.color': allLineColors
        };
        
        Plotly.restyle(gd, updateObj);
      }
      
      // FIXED: Function to reset highlighting
      function resetHighlighting() {
        if (!gd.data || !originalMarkerData) {
          return;
        }
        
        // Reset ALL traces, not just the first one
        var resetOpacities = [];
        var resetLineWidths = [];
        var resetLineColors = [];
        
        for (var i = 0; i < gd.data.length; i++) {
          resetOpacities.push(originalMarkerData.opacity);
          resetLineWidths.push(originalMarkerData.line.width);
          resetLineColors.push(originalMarkerData.line.color);
        }
        
        Plotly.restyle(gd, {
          'marker.opacity': resetOpacities,
          'marker.line.width': resetLineWidths,
          'marker.line.color': resetLineColors
        });
      }
      
      // Function to update annotations with milestones
      function updateAnnotations(year, showMilestone) {
        var currentAnnotations = [];
        
        // Add existing axis annotations (HDI and GDP income labels)
        if (gd.layout && gd.layout.annotations) {
          currentAnnotations = gd.layout.annotations.filter(function(ann) {
            // Keep only the axis labels, exclude any previous milestone annotations
            return ann.text && (ann.text.includes('HDI') || ann.text.includes('income')) && 
                   !ann.text.includes('Crisis') && !ann.text.includes('Spring') && 
                   !ann.text.includes('Collapse') && !ann.text.includes('Pandemic') && 
                   !ann.text.includes('Disaster') && !ann.text.includes('Decision') &&
                   !ann.text.includes('Wave') && !ann.text.includes('Financial') &&
                   !ann.text.includes('Migration');
          });
        }
        
        // Add milestone annotation at bottom right of the plot area
        if (showMilestone && milestoneTexts[year]) {
          currentAnnotations.push({
            text: milestoneTexts[year],
            x: 0.98, y: 0.02,
            xref: 'paper', yref: 'paper',
            xanchor: 'right', yanchor: 'bottom',
            showarrow: false,
            font: {size: 11, color: 'darkred', family: 'Arial'},
            bgcolor: 'rgba(255,255,255,0.95)',
            bordercolor: 'darkred',
            borderwidth: 2,
            borderpad: 8
          });
        }
        
        Plotly.relayout(gd, {annotations: currentAnnotations});
      }
      
      // Function to animate to specific year
      function animateToYear(year) {
        Plotly.animate(gd, [year.toString()], {
          transition: {duration: 300},
          frame: {duration: 0, redraw: false}
        }).then(function() {
          // Apply highlighting after animation if in timeline mode and it's a milestone
          if (isTimelineMode && milestoneYears.includes(year) && countryGroups[year.toString()]) {
            setTimeout(function() {
              highlightCountries(year, countryGroups[year.toString()]);
            }, 100);
          }
        });
        
        updateAnnotations(year, isTimelineMode && milestoneYears.includes(year));
      }
      
      // Custom animation function
      function customAnimate() {
        if (!isPlaying || currentYearIndex >= allYears.length) {
          stopAnimation();
          return;
        }
        
        var currentYear = allYears[currentYearIndex];
        animateToYear(currentYear);
        
        // Update stats and picker
        updateStats(currentYear);
        var picker = document.getElementById('year-picker');
        if (picker) picker.value = currentYear;
        
        // Check if this is a milestone year (only in timeline mode)
        var isMilestone = isTimelineMode && milestoneYears.includes(currentYear);
        var delay = isMilestone ? 8000 : 800; // 8s for milestone (longer to see highlighting), 0.8s for regular
        
        currentYearIndex++;
        animationTimeout = setTimeout(customAnimate, delay);
      }
      
      // Function to start animation
      function startAnimation(timelineMode, buttonElement) {
        // Stop any existing animation first
        stopAnimation();
        
        // Only reset to start if we're switching modes or not currently paused at a position
        if (isTimelineMode !== timelineMode || currentYearIndex === 0) {
          currentYearIndex = 0;
        }
        
        isTimelineMode = timelineMode;
        
        // Start new animation
        isPlaying = true;
        
        // Update button text
        if (timelineMode) {
          buttonElement.textContent = '⏸ Pause Timeline';
        } else {
          buttonElement.textContent = '⏸ Pause';
        }
        
        customAnimate();
      }
      
      // Override both play buttons behavior - now looking for bottom buttons only
      function setupCustomControls() {
        // Remove any existing top buttons
        var topButtons = document.querySelectorAll('.updatemenu-button');
        topButtons.forEach(function(btn) {
          if (btn.closest('.js-plotly-plot')) {
            btn.style.display = 'none';
          }
        });
        
        // Setup bottom custom buttons
        setupBottomButtons();
      }
      
      function setupBottomButtons() {
        var timelineBtn = document.getElementById('timeline-btn');
        var playBtn = document.getElementById('play-btn');
        
        if (timelineBtn) {
          timelineBtn.onclick = function(e) {
            e.preventDefault();
            e.stopPropagation();
            
            if (isPlaying && isTimelineMode) {
              stopAnimation();
            } else {
              startAnimation(true, this);
            }
          };
        }
        
        if (playBtn) {
          playBtn.onclick = function(e) {
            e.preventDefault();
            e.stopPropagation();
            
            if (isPlaying && !isTimelineMode) {
              stopAnimation();
            } else {
              startAnimation(false, this);
            }
          };
        }
        
        if (!timelineBtn || !playBtn) {
          setTimeout(setupBottomButtons, 100);
        }
      }
      
      // Setup custom controls after plotly renders
      setTimeout(setupCustomControls, 1000);
      
      // --- Original stats panel functionality ---
      var picker = document.getElementById('year-picker');
      if (picker && picker.options.length === 0 && years && years.length) {
        years.forEach(function(y) {
          var opt = document.createElement('option');
          opt.value = y;
          opt.text = y;
          picker.appendChild(opt);
        });
        picker.onchange = function() {
          // Stop any running animation when user manually selects year
          stopAnimation();
          
          var selectedYear = parseInt(this.value);
          updateStats(selectedYear);
          
          // Check if manually selected year is a milestone and we're in timeline mode
          var shouldShowMilestone = isTimelineMode && milestoneYears.includes(selectedYear);
          updateAnnotations(selectedYear, shouldShowMilestone);
          
          // Apply highlighting if in timeline mode and it's a milestone year
          if (shouldShowMilestone && countryGroups[selectedYear.toString()]) {
            setTimeout(function() {
              highlightCountries(selectedYear, countryGroups[selectedYear.toString()]);
            }, 100);
          } else {
            resetHighlighting(); // Reset highlighting for non-milestone years or regular mode
          }
          
          if (gd) {
            Plotly.animate(gd, {frame: {redraw: true, name: this.value}}, {mode: 'immediate'});
          }
        };
      }

      var sl = document.getElementById('size-legend');
      if (sl && !sl.innerHTML) {
        sl.style.background = 'rgba(255,255,255,0.97)';
        sl.style.borderRadius = '8px';
        sl.style.boxShadow = '0 0 6px rgba(0,0,0,0.10)';
        sl.style.fontSize = '13px';
        sl.style.padding = '10px 10px';
        sl.innerHTML =
          '<div><strong>Circles sized by</strong><br>Population</div>' +
          '<div style=\"margin-top:6px;display:flex;align-items:center;gap:6px;\">' +
            '<div style=\"width:20px;height:20px;border-radius:50%;background:lightgray;\"></div>' +
            '<span>600&nbsp;M</span>' +
          '</div>' +
          '<div style=\"margin-top:4px;display:flex;align-items:center;gap:6px;\">' +
            '<div style=\"width:30px;height:30px;border-radius:50%;background:lightgray;\"></div>' +
            '<span>1.4&nbsp;B</span>' +
          '</div>';
      }

      function updateStats(year) {
        var sb = document.getElementById('stats-box');
        if (!sb) return;
        var sub  = stats.filter(d => +d.Year == +year);
        var devo = sub.find(d => d.dev_status=='Developing'),
            dev  = sub.find(d => d.dev_status=='Developed');
        if (!devo || !dev) {
          sb.innerHTML = '<span style=\"color:red;\">No data for year ' + year + '</span>';
          return;
        }
        sb.style.background = 'rgba(255,255,255,0.98)';
        sb.style.borderRadius = '10px';
        sb.style.boxShadow = '0 2px 10px rgba(0,0,0,0.11)';
        sb.style.fontSize = '14px';
        sb.style.padding = '12px 12px';
        sb.innerHTML =
          '<b>Year: ' + year + '</b><br>' +
          '<b>Developing</b><br>' +
          devo.count + ' countries<br>' +
          'Avg GDP: $' + (+devo.avg_gdp).toLocaleString() + '<br>' +
          'Avg HDI: '  + (+devo.avg_hdi).toFixed(3) + '<br><br>' +
          '<b>Developed</b><br>' +
          dev.count  + ' countries<br>' +
          'Avg GDP: $' + (+dev.avg_gdp).toLocaleString() + '<br>' +
          'Avg HDI: '  + (+dev.avg_hdi).toFixed(3);
      }

      // Initialize with first year
      if (picker && years && years.length) {
        picker.value = years[0];
        updateStats(years[0]);
        updateAnnotations(years[0], false);
        if (gd) {
          Plotly.animate(gd, {frame: {redraw: true, name: years[0]}}, {mode: 'immediate'});
        }
      }
    }
  ")

5 Final Improved Visualization